Skip to content

perf(semaphore): streamline permit distribution to waiting acquirers - #335

Closed
BewareMyPower wants to merge 10 commits into
apache:mainfrom
BewareMyPower:bewaremypower/refactor-insert_permits_with_lock
Closed

BewareMyPower wants to merge 10 commits into
apache:mainfrom
BewareMyPower:bewaremypower/refactor-insert_permits_with_lock

Conversation

@BewareMyPower

@BewareMyPower BewareMyPower commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Rework how Semaphore distributes released permits to waiting acquirers so the distribution no longer runs inside Drop during unwinding.

  • insert_permits_with_lock collects up to WakerBatch::STACK_SIZE wakers under the wait-queue lock, releases the lock, and hands the batch to wake_all by mutable reference, repeating until the permits are exhausted.
  • wake_all keeps owning the "wake every waker, keep the first panic" policy. The loop retains the first panic payload in a local, keeps distributing on the normal path, and rethrows it with resume_unwind once the release is complete.
  • Passing &mut batch instead of the owned batch avoids copying the inline waker storage into wake_all for every batch of 32 waiters.

Semaphore::release keeps its documented contract: added permits remain available, every eligible waiter is still notified, and the first wake panic still reaches the caller.

Design Notes

The previous implementation passed a lazily refilling iterator to wake_all and relied on WakeRemaining::drop to keep pulling batches during unwinding, so a panicking wake callback could not stop the remaining permits from being distributed. That is correct, but it runs the permit accounting, the lock acquisition, and the wait-list mutations while the thread is unwinding — the hardest state to reason about, and the reason the loop read as an iterator closure rather than as the distribution itself.

Keeping the loop on the normal path makes the failure behavior explicit. Only the wake callbacks stay inside catch_unwind, and the retained payload is a plain local rather than a guard type: rethrowing from the normal path also means a later panic (the permit-overflow assert!) no longer resumes the retained payload while the thread is already unwinding.

Performance

cargo bench -p benchmarks --bench benchmarks -- release_to_waiters, median of six runs with the revisions interleaved in both orders and a fresh compile per revision (main is the released implementation; absolute values are from one machine):

waiting acquirers released this PR change
1 13.4 ns 11.0 ns -18%
2 16.70 ns 13.4 ns -20%
32 162 ns 152.4 ns -6%
256 1.136 µs 1.105 µs -3%

The 1- and 2-waiter rows are stable across runs and reproduce an 18-20% reduction in release-to-waiter latency. The 32- and 256-waiter rows move by a few percent, within the run-to-run spread on the development machine, and are best read as neutral to slightly better.

Validation

  • cargo x test, cargo x lint, and cargo x check (all 27 feature combinations under -D warnings).
  • internal::semaphore::tests::panicking_wakes_preserve_permits_and_the_first_panic covers the panic path: 65 waiters, two panicking wake callbacks, all 65 woken exactly once, remaining permits correct, first panic preserved.

Comment thread asyncband/src/internal/semaphore.rs Outdated
}

struct PanicGuard {
first: Option<Box<dyn Any + Send>>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we would like to have such a dynamic thing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I make the guard inline now. Based on the current design, the dynamic dyn Any + Send is necessary because catch_unwind call on a closure returns Result<(), Box<dyn Any + Send>>.

`insert_permits_with_lock` woke each batch of wakers while it was still
distributing permits, so a wake callback that panicked had to be caught, its
payload retained in a `PanicGuard`, and rethrown once the release finished.

Collect every waker first and notify after the accounting is complete instead.
A wake panic can no longer distort the distribution, and `PanicGuard`, the
per-batch `catch_unwind`, and the `will_spill` chunk boundary all go away.

Holding every waker until the release finishes lets the batch outgrow its inline
storage on wide releases, and dropping the chunk boundary costs more than the
removed `catch_unwind`. Measured against the previous commit (`divan`,
`release_to_waiters`, six runs): 1 waiter 10.5 -> 11.2 ns, 2 waiters
13.8 -> 14.7 ns, 32 waiters 153 -> 188 ns, 256 waiters 1.11 -> 1.80 us.

The changelog entry from the previous commit is dropped because the net effect
against the latest release is no longer a clear improvement.
This reverts commit 45e202b. Holding every waker until the release finishes
spills the batch to the heap on wide releases, and dropping the chunk boundary
cost more than the removed `catch_unwind`; against the previous commit
(`divan`, `release_to_waiters`, six runs) 32 waiters went 153 -> 188 ns and
256 waiters 1.11 -> 1.80 us.
The payload only needs to survive until the release completes, so a local
`Option<Box<dyn Any + Send>>` and a trailing `resume_unwind` express the same
thing as the guard type without the `Drop` impl.

Rethrowing on the normal path instead of from `Drop` also removes an abort: if
the permit-overflow `assert!` fired after a wake panic had been recorded, the
guard resumed the retained payload while the thread was already unwinding. The
retained payload is now dropped during that unwind, so the overflow panic
reaches the caller.
@BewareMyPower

Copy link
Copy Markdown
Contributor Author
waiting acquirers main 6fb5298 56dfca8 56dfca8 vs main
1 13.4 ns 10.8 ns 11.0 ns -18%
2 16.70 ns 13.4 ns 13.4 ns -20%
32 162 ns 153 ns 152.4 ns -6%
256 1.136 µs 1.102 µs 1.105 µs -3%

@QwQBiG

QwQBiG commented Sep 30, 2026

Copy link
Copy Markdown
Member

The explicit loop is easier to follow, and it preserves the first-panic behavior across batches.

Tests on Rust 1.86 and 1.98, Miri, and feature checks passed locally; the benchmarks improved too.

Small nit: the description still mentions PanicGuard. ^w^

@BewareMyPower

Copy link
Copy Markdown
Contributor Author

PR description is updated now.

P.S. I told my agent to only generate a new summary without running gh command to edit PR description, but I forgot to update.

@tisonkun tisonkun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After a closer look I consider the let mut first_panic = None; is the second implementation for what wake_all delievered already. The back and forth catch_unwind/rewind seems putting more indirection.

Then I think the main branch version would be better in this case. Sorry to provide wrong insight.

@tisonkun tisonkun closed this Sep 30, 2026
@BewareMyPower
BewareMyPower deleted the bewaremypower/refactor-insert_permits_with_lock branch September 30, 2026 11:25
@BewareMyPower

Copy link
Copy Markdown
Contributor Author

The back and forth catch_unwind/rewind seems putting more indirection.

Yes. Initially, I want to use the following pattern (written with Java pseudo code)

Throwable firstThrowable = null;
while (this.permits > 0) {
    var batch = this.pollAndReducePermits(this.wakers);
    if (batch.empty()) break;
    for (var waker : batch) {
        try {
            waker.wake();
        } catch (Throwable t) {
            if (firstThrowable == null) firstThrowable = t;
        }
    }
}
if (firstThrowable != null) throw firstThrowable;

However, since the wake_all method is still called directly in this PR, this PR actually looks like:

Throwable firstThrowable = null;
while (this.permits > 0) {
    var batch = this.pollAndReducePermits(this.wakers);
    if (batch.empty()) break;
    Throwable innerThrowable = null;
    for (var waker : batch) {
        try {
            waker.wake();
        } catch (Throwable t) {
            if (innerThrowable == null) innerThrowable = t;
        }
    }
    if (firstThrowable == null && innerThrowable != null) firstThrowable = innerThrowable;
}
if (firstThrowable != null) throw firstThrowable;

Then things become different.

Sorry to provide wrong insight.

I don't think so. Without looking deeper into the wake_all implementation, the current implementation is still not straightforward.

The current implementation in main branch

wake_all(std::iter::from_fn(|| {
    loop {
        if let Some(waker) = batch.next() { // [1]
            return Some(waker);
        }
        // [2]...
    }
}));

Initially, batch is empty, so it will start from [2] (step 1):

  1. batch receives the wait nodes from waiters to the batch in each iteration until batch's stack storage is full. (that's what [2] does)
  2. Poll the waker from the batch to wake_all, which calls the wake() method.
  3. wake_all will repeat step 2 until any panic happens.
    1. If no panic happens in the batch, batch.next() in [1] will return None, then go to step 1 ([2]) again.
    2. Otherwise, wake_all will continue [2] in WakeRemaining::drop, but any panic will be ignored.

Eventually, the if rem == 0 { return None; } at the beginning of [2] will end the whole loop, where rem is guaranteed to be reduced to 0 in the rest implementation of [2].

I noticed other places like Shared::drop_sender also follows this wake_all pattern:

wake_all(std::iter::from_fn(|| waiters.notify_one()));

Anyway, it's still not straightforward and could be confusing.

I think the root cause for such indirection comes from the poorly designed wake_all method. We'd better express "resume-first-panic" clearly rather than coupling such panic handling pattern in a method whose name is "triggering a method on all elements in a collection".

That's why I've introduced PanicGuard before, but it might not be a good idea to apply it only in this single call.

Why this PR has better performance

This is interesting, so I told my agent to investigate for a while. In short, the improvement mainly comes from inlining, if a future LLVM inlines main's nested closure, the difference disappears.

1. should_unlink

In main, the should_unlink closure passed to unlink_first_waiter is emitted as its own function (WaitList::unlink_first_waiter::{{closure}} @ 0x1001564f8), and insert_permits_with_lock really calls it once per unlinked waiter:

10015a894: bl 0x1001564f8   ; predicate closure, inside the unlink loop

In this PR, the same predicate is inlined: just straight-line permit accounting and list updates. Reason: in main the predicate is nested inside the from_fn closure (insert_permits_with_lock::{{closure}}::{{closure}}), so LLVM outlines it. Cost ≈ 0.1 ns per waiter → ~24 ns at 256 waiters.

2. Lazy iterator

main routes the whole release through the inlined from_fn closure, and WakeRemaining::drop is outlined and carries that closure body. So the closure body is entered ~3× per release. That is a ~2 ns fixed cost, measurable with zero waiters, where no predicate is ever called:

release (no waiters):  main 21.1 / 21.8 ns   vs   current 19.8 / 19.8 ns

3. The refactor's extra catch_unwind is free on the success path.

No __rust_try call — only a cold landing pad (catch_unwind::cleanup) and the resume_unwind path. It costs code size (429 vs 335 instructions), not runtime, so it does not offset 1 and 2.

Net: ~2 ns fixed + ~0.1 ns/waiter, which is exactly the measured shape (1 waiter −2 ns, 2 waiters −3 ns, 32 within noise, 256 −24 ns).

@tisonkun

Copy link
Copy Markdown
Member

@BewareMyPower I agree a new abstraction can be good to add. Just not co-exist and introduce one more indirection - replacing the current one is OK.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants